game_manager_lib\services\recommendation/
scoring.rs

1//! Sistema de Scoring de Recomendações
2//!
3//! Este módulo contém toda a lógica de cálculo de scores para recomendações,
4//! incluindo content-based e collaborative filtering.
5
6use super::core::*;
7use crate::utils::tag_utils::{combined_multiplier, TagKey, TagRole};
8use chrono::Datelike;
9
10// === ESTRUTURAS AUXILIARES ===
11
12#[derive(Debug, Clone)]
13pub struct DetailedScoreComponents {
14    pub affinity_score: f32,
15    pub context_score: f32,
16    pub diversity_score: f32,
17    pub genre_score: f32,
18    pub tag_score: f32,
19    pub series_score: f32,
20    pub age_penalty: f32,
21    pub top_genres: Vec<(String, f32)>,
22    pub top_affinity_tags: Vec<(String, f32)>,
23    pub top_context_tags: Vec<(String, f32)>,
24}
25
26// === FUNÇÕES DE SCORING ===
27
28/// Calcula score content-based de um jogo
29pub fn score_game_cb(
30    profile: &UserPreferenceVector,
31    game: &GameWithDetails,
32    config: &RecommendationConfig,
33) -> (f32, Option<RecommendationReason>) {
34    let (total_cb, reason, _components) = score_game_cb_detailed(profile, game, config);
35
36    (total_cb, reason)
37}
38
39/// Versão detalhada do score content-based com breakdown completo
40pub fn score_game_cb_detailed(
41    profile: &UserPreferenceVector,
42    game: &GameWithDetails,
43    config: &RecommendationConfig,
44) -> (f32, Option<RecommendationReason>, DetailedScoreComponents) {
45    let mut affinity_score = 0.0;
46    let mut context_score = 0.0;
47    let mut diversity_score = 0.0;
48
49    let mut genre_score = 0.0;
50    let mut tag_score = 0.0;
51    let mut series_score = 0.0;
52
53    let mut genre_contributions = Vec::new();
54    let mut affinity_tag_contributions = Vec::new();
55    let mut context_tag_contributions = Vec::new();
56
57    let mut best_reason: Option<RecommendationReason> = None;
58    let mut max_affinity_contribution = 0.0;
59
60    // 1. Processar Gêneros
61    process_genres(
62        &game.genres,
63        &profile.genres,
64        &mut affinity_score,
65        &mut genre_score,
66        &mut genre_contributions,
67        &mut best_reason,
68        &mut max_affinity_contribution,
69    );
70
71    // 2. Processar Tags
72    process_tags(
73        &game.tags,
74        &profile.tags,
75        &mut affinity_score,
76        &mut context_score,
77        &mut diversity_score,
78        &mut tag_score,
79        &mut affinity_tag_contributions,
80        &mut context_tag_contributions,
81        &mut best_reason,
82        &mut max_affinity_contribution,
83    );
84
85    // 3. Processar Séries
86    if config.favor_series {
87        process_series(
88            &game.series,
89            &profile.series,
90            &mut affinity_score,
91            &mut series_score,
92        );
93    }
94
95    // 4. Aplicar Penalização por Idade
96    let age_penalty = apply_age_penalty(
97        game.release_year,
98        config.age_decay,
99        &mut affinity_score,
100        &mut context_score,
101    );
102
103    let total_cb = affinity_score + context_score + diversity_score;
104
105    // Ordenar contribuições
106    genre_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
107    affinity_tag_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
108    context_tag_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
109
110    let components = DetailedScoreComponents {
111        affinity_score,
112        context_score,
113        diversity_score,
114        genre_score,
115        tag_score,
116        series_score,
117        age_penalty,
118        top_genres: genre_contributions.into_iter().take(5).collect(),
119        top_affinity_tags: affinity_tag_contributions.into_iter().take(10).collect(),
120        top_context_tags: context_tag_contributions.into_iter().take(5).collect(),
121    };
122
123    (total_cb, best_reason, components)
124}
125
126/// Normaliza um score baseado no valor máximo
127pub fn normalize_score(score: f32, max: f32) -> f32 {
128    if max > 0.0 {
129        score / max
130    } else {
131        0.0
132    }
133}
134
135// === FUNÇÕES AUXILIARES DE PROCESSAMENTO ===
136
137fn process_genres(
138    game_genres: &[String],
139    profile_genres: &std::collections::HashMap<String, f32>,
140    affinity_score: &mut f32,
141    genre_score: &mut f32,
142    genre_contributions: &mut Vec<(String, f32)>,
143    best_reason: &mut Option<RecommendationReason>,
144    max_affinity_contribution: &mut f32,
145) {
146    for genre in game_genres {
147        if let Some(&val) = profile_genres.get(genre) {
148            let contribution = val * WEIGHT_GENRE;
149            *affinity_score += contribution;
150            *genre_score += contribution;
151            genre_contributions.push((genre.clone(), contribution));
152
153            if contribution > *max_affinity_contribution {
154                *max_affinity_contribution = contribution;
155                *best_reason = Some(RecommendationReason {
156                    label: format!("Gênero: {}", genre),
157                    type_id: "genre".to_string(),
158                });
159            }
160        }
161    }
162}
163
164fn process_tags(
165    game_tags: &[crate::models::GameTag],
166    profile_tags: &std::collections::HashMap<TagKey, f32>,
167    affinity_score: &mut f32,
168    context_score: &mut f32,
169    diversity_score: &mut f32,
170    tag_score: &mut f32,
171    affinity_tag_contributions: &mut Vec<(String, f32)>,
172    context_tag_contributions: &mut Vec<(String, f32)>,
173    best_reason: &mut Option<RecommendationReason>,
174    max_affinity_contribution: &mut f32,
175) {
176    for tag in game_tags {
177        let key = TagKey::new(tag.category.clone(), tag.slug.clone());
178
179        if let Some(&pref_val) = profile_tags.get(&key) {
180            let multiplier = combined_multiplier(&tag.category, &tag.role);
181            let base_contribution = pref_val * multiplier * WEIGHT_PLAYTIME_HOUR;
182            let contribution = base_contribution.min(MAX_TAG_CONTRIBUTION);
183
184            match tag.role {
185                TagRole::Affinity => {
186                    *affinity_score += contribution;
187                    *tag_score += contribution;
188                    affinity_tag_contributions.push((tag.name.clone(), contribution));
189
190                    if contribution > *max_affinity_contribution {
191                        *max_affinity_contribution = contribution;
192                        *best_reason = Some(RecommendationReason {
193                            label: format!("Tag: {}", tag.name),
194                            type_id: "tag".to_string(),
195                        });
196                    }
197                }
198                TagRole::Context => {
199                    *context_score += contribution;
200                    *tag_score += contribution;
201                    context_tag_contributions.push((tag.name.clone(), contribution));
202                }
203                TagRole::Diversity => {
204                    *diversity_score += contribution;
205                    *tag_score += contribution;
206                }
207                TagRole::Filter => {}
208            }
209        }
210    }
211}
212
213fn process_series(
214    game_series: &Option<String>,
215    profile_series: &std::collections::HashMap<String, f32>,
216    affinity_score: &mut f32,
217    series_score: &mut f32,
218) {
219    if let Some(series_name) = game_series {
220        if let Some(&val) = profile_series.get(series_name) {
221            let series_contribution = val.sqrt();
222            *affinity_score += series_contribution;
223            *series_score = series_contribution;
224        }
225    }
226}
227
228fn apply_age_penalty(
229    release_year: Option<i32>,
230    age_decay: f32,
231    affinity_score: &mut f32,
232    context_score: &mut f32,
233) -> f32 {
234    let mut age_penalty = 1.0;
235
236    if let Some(release_year) = release_year {
237        let current_year = chrono::Local::now().year();
238        let age = (current_year - release_year).clamp(0, 15);
239        if age > 0 {
240            age_penalty = age_decay.powi(age);
241            *affinity_score *= age_penalty;
242            *context_score *= age_penalty;
243        }
244    }
245
246    age_penalty
247}